Spring Security 프로젝트 설정 9 - JWT 로그아웃
✒️ 2025-05-28 14:27 내용 수정
- code : https://github.com/ase10git/SpringSecurityTest
- SpringSecurity 프로젝트 설정 목록
- Spring Security 기본 사용자 추가 및 테스트
- Spring Security 프로젝트 설정 1 - DB연결과 JPA 설정
- Spring Security 프로젝트 설정 2 - JwtService와 Filter 설정
- Spring Security 프로젝트 설정 3 - Security Config
- Spring Security 프로젝트 설정 4 - Authentication Service와 Controller
- Spring Security 프로젝트 설정 5 - Security CORS 설정
- Spring Security 프로젝트 설정 6 - JWT Refresh Token 생성 및 저장
- Spring Security 프로젝트 설정 7 - JWT Refresh Token 재발급
- Spring Security 프로젝트 설정 8 - JWT 클라이언트 저장
- Spring Security 프로젝트 설정 9 - JWT 로그아웃
- Spring Security 프로젝트 설정 10 - 권한 설정
로그아웃 동작
참고 자료 : Learn With Ifte's JWT Logout: The Trick You Missed in Spring Security, Bouali Ali's How to Logout from Spring Security - JWT
참고 노트 : Spring Security Logout
- 클라이언트에서 Access Token을 로그아웃 요청과 함께 전송한다.
- Access Token은 Authorization Header에 담는다.
- 서버에선 요청에서 Access Token을 먼저 검증하고, 유효한 Access Token일 경우 해당 사용자의 모든 Refresh Token을 DB에서 조회한다.
- Refresh Token이 존재할 경우 모든 Refresh Token을 삭제한다.
- 사용자가 요청과 함께 보낸 Access Token을
BlackList라는 Table의 Entity에 저장한다.- 사용자가 마지막으로 사용한 Access Token으로 다시 요청을 보낼 수 없도록 하는 역할이다.
- 서버에서 클라이언트로 로그아웃 결과를 전송한다.
Token 처리에 관한 고민
- 현재 이 Security 프로젝트에선 DB에 저장하는 Token은 Refresh Token이며, Access Token은 저장하지 않고 있다.
- 초기엔 Refresh Token을 저장하는
TokenEntity에서isLoggedOutfield를 추가하여 만료 처리를 하려고 했다.- 하지만 Token 재발급때 기존 Refresh Token을 바로 제거하는 동작을 추가했기에 굳이 field로 만료 처리를 true로 하고 이후에 삭제하는 동작이 의미가 없는 것 같아 바로 제거하기로 했다.
- Access Token은 따로 만료 처리를 할 수 없어 로그아웃 동작 시에 사용한 Access Token과 로그아웃 이후에 그 Token을 어떻게 처리할지 문제였다.
- Redis에 Token을 저장하여 관리하는 경우, 로그아웃 동작 시 Redis에 저장된 Refresh Token을 제거하고, Access Token을 저장해서 해당 Access Token으로 다른 요청이 들어오면 이미 로그아웃된 사용자의 Access Token임을 확인하는 방법이 있다.
- 프로젝트에선 Redis 대신 사용하던 DB에 Token을 저장하고 있어 상세한 구현이 다르지만, 로그아웃 시에 사용자가 가지고 있던 Access Token을 black list에 추가하여 이후 요청에서 해당 Token을 사용할 수 없도록 구현할 수 있을 것 같았다.
- 원래는 기존 Token Entity에 Access Token과 Refresh Token을 함께 저장하여 관리해야 하는지 고민했는데, 이 방법을 쓰면 "중요한 건 Access Token인데 이거 저장하면 Refresh Token은 왜 써야하지?" 하는 질문을 해결할 수 없었고, 결국 JWT를 쓰기보다 Session을 사용하는 것과 더 차이가 없는 것처럼 느껴졌다.
서버 설정
BlackList Entity와 Repository 추가
BlackListEntity를 추가한다.- 이 Entity(테이블)에선 말 그대로 접근 금지 처리되는 Access Token을 저장한다.
- 로그아웃 수행 후 사용자가 마지막으로 사용한 Access Token으로 다시 요청을 보낼 수 없도록 하기 위해 사용한다.
package com.example.security.token;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Entity
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "blacklist")
public class BlackList {
@Id
// ID는 자동 생성 전략 사용
@GeneratedValue(strategy = GenerationType.AUTO)
private Integer id;
private String accessToken;
}
- Entity를 다룰 Repository도 추가한다.
- Repository의 메소드는 Access Token으로 검색하는
findByAccessToken만 추가하였다.
- Repository의 메소드는 Access Token으로 검색하는
package com.example.security.token;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface BlackListRepository extends JpaRepository<BlackList, Integer> {
// Access Token으로 검색
Optional<BlackList> findByAccessToken(String token);
}
JwtService 수정
JwtService에서 Access Token의 유효성을 검사하는 메소드에서BlackList에 Access Token이 등록되어 있는지 확인하는 과정을 추가한다.- Access Token이
BlackList에 추가되었다면 Access Token 자체가 만료되지 않았음에도 이미 로그아웃 때 사용되어 더 이상 유효하지 않은 Token이다.
- Access Token이
package com.example.security.config;
import com.example.security.token.BlackList;
import com.example.security.token.BlackListRepository;
import com.example.security.token.Token;
import com.example.security.token.TokenRepository;
import com.example.security.user.User;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
@Service
@RequiredArgsConstructor
@Slf4j
public class JwtService {
// DB와 상호작용하는 token repo
private final TokenRepository tokenRepository;
// blacklist
private final BlackListRepository blackListRepository;
// ... 생략
// Access Token 유효성 검사
public boolean isAccessTokenValid(String token, UserDetails userDetails) {
final String username = extractUsername(token);
// Access Token이 BlackList에 있는지 조회
BlackList blackList = blackListRepository.findByAccessToken(token).orElse(null);
boolean isBlackListToken = (blackList != null);
// 토큰의 사용자 정보가 DB의 정보와 일치 여부 + 만료 기한 확인
// DB에 사용자 정보가 없다면 여기서 false를 반환하여 유효하지 않음을 확인
return (username.equals(userDetails.getUsername()))
&& !isTokenExpired(token)
&& !isBlackListToken;
}
// ... 생략
}
SecurityConfig 수정
SecurityConfig에서 로그아웃 설정을 추가한다.logoutUrl에 로그아웃 endpoint를 설정한다.addLogoutHandler에서 로그아웃을 관리할 Custom Handler를 추가할 수 있다.logoutSuccessHandler에서 로그아웃 성공 시 동작을 설정할 수 있으며, 여기선SecurityContextHolder의 내용을 비우도록 설정했다.
package com.example.security.config;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Collections;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthFilter;
private final AuthenticationProvider authenticationProvider;
private final CustomLogoutHandler customLogoutHandler;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{
http
// session stateless로 인해 꺼 둠
.csrf((auth)->auth.disable())
.authorizeRequests()
.requestMatchers("/api/v1/auth/**") // 나열된 요청들은
.permitAll() // 모두 허용
.anyRequest() // 그 외의 모든 요청은
.authenticated() // 인증 필요
.and()
.sessionManagement((session)->
session // session state는 저장되면 안되므로 stateless로 설정
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authenticationProvider(authenticationProvider)
.addFilterBefore(jwtAuthFilter,
UsernamePasswordAuthenticationFilter.class); // jwt 필터 가동
// cors 설정
http
.cors((corsConfigurer)->
corsConfigurer
.configurationSource(corsConfigurationSource()));
// logout 설정
http
.logout(log->log.logoutUrl("/api/v1/auth/logout")
.addLogoutHandler(customLogoutHandler)
.logoutSuccessHandler(
(request, response, authentication)
-> SecurityContextHolder.clearContext()
)
);
return http.build();
}
// ... 생략
}
CustomLogoutHandler 추가
LogoutHandler를 구현하는CustomLogoutHandler를 추가하여 로그아웃을 처리하는logout메소드를 오버라이드한다.- 여기서 요청의 Authorization Header에 있는 Access Token을 추출하고, Token의 유효성 검증(사용자 체크, 만료일 체크,
BlackList등록 여부)를 확인한다. - 유효한 Access Token일 경우 해당 Access Token을
BlackList에 추가하여 더 이상 같은 Access Token으로 요청할 수 없도록 한다. - DB에서 해당 사용자의 Refresh Token으로 등록된
Token을 찾아 모두 제거한다.- Entity의 구현 방법에 따라
Token의isLoggedOut이나revoked와 같은 field를 true로 바꾸는 동작으로 변경할 수도 있다.
- Entity의 구현 방법에 따라
- 여기서 요청의 Authorization Header에 있는 Access Token을 추출하고, Token의 유효성 검증(사용자 체크, 만료일 체크,
package com.example.security.config;
import com.example.security.token.BlackList;
import com.example.security.token.BlackListRepository;
import com.example.security.user.User;
import com.example.security.user.UserRepository;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.LogoutHandler;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class CustomLogoutHandler implements LogoutHandler {
// DB와 상호작용하는 사용자 repo
private final UserRepository userRepository;
// Token Blacklist
private final BlackListRepository blackListRepository;
// jwt 서비스
private final JwtService jwtService;
@Override
public void logout(
HttpServletRequest request,
HttpServletResponse response,
Authentication authentication)
{
// 요청에서 Header 가져오기
final String authHeader = request.getHeader("Authorization");
// Authorization Header가 없으면 Access Token이 없으므로 failed
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
return;
}
// Access Token 추출
String token = authHeader.substring(7);
String userEmail = jwtService.extractUsername(token);
// token 내 이메일 확인
if (userEmail != null) {
// 사용자 검색
User user = userRepository.findByEmail(userEmail).orElse(null);
// token 유효성 검사
if (user != null && jwtService.isAccessTokenValid(token, user)) {
// Access Token을 BlackList에 추가
BlackList blackList = new BlackList();
blackList.setAccessToken(token);
// BlackList 저장
blackListRepository.save(blackList);
// 기존에 db에 저장된 사용자의 모든 Refresh Token 제거
jwtService.removeAllUserToken(user);
}
}
}
}
클라이언트 설정
- 간단한 테스트를 위해 React 클라이언트에 로그아웃 API를 호출할 함수와 버튼을 추가했다.
import axios from "axios";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
function Demo() {
const [hello, setHello] = useState('');
const navigate = useNavigate();
// token 재발급
const refreshToken = async () => {
try {
const res = await axios.post(
'/auth/refresh-token',
{},
{withCredentials: true}
);
// 응답에서 Access Token 가져와 로컬 변수에 저장
const {access_token} = res.data;
// Access Token을 axios의 header의 Authorization Bearer Schema에 적용
axios.defaults.headers.common['Authorization'] = `Bearer ${access_token}`;
} catch (error) {
alert("로그인을 다시 해주세요");
navigate('/login');
}
}
// 결과에 따라 재요청을 처리
const fetchWithRetry = async (url, options = {}) => {
try {
const res = await axios.get(url, options);
return res.data;
} catch (error) {
// 응답이 401이나 403이면 token이 없는 상태
if (error.response &&
(error.response.status === 401
|| error.response.status === 403)) {
// token 재발급
await refreshToken();
// 다시 자원 요청
const res = await axios.get(url, options);
return res.data;
} else {
throw error;
}
}
}
// secured endpoint의 자원 요청하기
const getResource = async () => {
// 자원 요청
try {
const data = await fetchWithRetry('/demo-controller');
setHello(data);
} catch (error) {
setHello("Not Authorized");
}
}
// 로그아웃
const logout = async () => {
try {
await axios.get("/auth/logout");
} catch (error) {
}
}
// 최초 렌더링 때만 자원 요청 함수 실행
useEffect(()=>{
getResource();
}, []);
return(
<div>
<h2>Demo Page</h2>
<button onClick={()=>{navigate("/register")}}>회원가입</button>
<button onClick={()=>{navigate("/login")}}>로그인</button>
<button onClick={logout}>로그아웃</button>
<p>{hello}</p>
</div>
)
}
export default Demo;
Test
- 애플리케이션을 실행하고, 회원가입과 로그인 절차를 끝내면 서버로부터 Access Token과 Refresh Token을 받는다.
- Application의 Cookies에서도 확인할 수 있다.
- DB에서 Token을 확인해보면 방금의 로그인으로 Refresh Token이 저장되어 있다.
- 이제 로그아웃 요청을 보내면 Request Header의 Authorization Bearer Schema에 Access Token이 담긴 것을 확인할 수 있다.
- DB에서 BlackList 테이블을 확인하면 Access Token이 BlackList에 추가된 것을 확인할 수 있다.
- Token 테이블에는 Refresh Token이 삭제되어 있다.
- 이 상태로 새로 고침을 수행해서
/refresh-token요청을 보내면 이미 클라이언트에 저장된 Refresh Token은 DB에 존재하지 않으므로 요청이 실패하게 된다.